Skip to content

Conversation

henrikjacobsenfys
Copy link
Member

Utility function to calculate the detailed balance factor as discussed here: easyscience/dynamics#6

Includes the option to normalise by temperature. Takes various inputs and outputs a np array. I may want to later output a scipp array, but not yet.

Copy link

codecov bot commented Sep 23, 2025

Codecov Report

❌ Patch coverage is 91.35802% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.35%. Comparing base (3d99faa) to head (550bbc6).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
src/easydynamics/utils/detailed_balance.py 91.13% 7 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           develop      #52       +/-   ##
============================================
+ Coverage     0.00%   91.35%   +91.35%     
============================================
  Files            1        2        +1     
  Lines            2       81       +79     
============================================
+ Hits             0       74       +74     
- Misses           2        7        +5     
Flag Coverage Δ
unittests 91.35% <91.35%> (+91.35%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI added a commit that referenced this pull request Sep 23, 2025
@henrikjacobsenfys henrikjacobsenfys added [scope] enhancement Adds/improves features (major.MINOR.patch) [priority] medium Normal/default priority labels Sep 23, 2025
Copy link
Member

@rozyczko rozyczko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea with separating utilities from the rest of the code.
A few points added on how to improve the code.
The rest looks good!

"""

# Input validation
if not isinstance(divide_by_temperature, bool):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type checks already test the types. If we enforce those, these checks aren't necessary

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a user-facing function, input validation is always a good idea since you have no guarantee that the users have any clue what they're doing :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it's mostly to be used internally, but I can't rule out that users will want to use it.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be perfectly clear wether a function/method is user-facing or not.
For user-facing functions you need input validation checks like these and thoroughly documented docstrings.
For developer/hidden functions you don't as you always know what you pass to the function. Also, those methods should always start with an _.

@henrikjacobsenfys
Copy link
Member Author

@rozyczko Just double checking, is it OK to merge? :)

Copy link
Member

@rozyczko rozyczko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good now!

Copy link

@damskii9992 damskii9992 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will continue tomorrow

"""

# Input validation
if not isinstance(divide_by_temperature, bool):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a user-facing function, input validation is always a good idea since you have no guarantee that the users have any clue what they're doing :)


# Input validation
if not isinstance(divide_by_temperature, bool):
raise ValueError("divide_by_temperature must be a boolean.")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would write "must be True or False" rather than "must be boolean", slightly easier to understand for non-programmers ;)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this should be a type error

# Convert to numpy array first for consistent handling
if isinstance(value, (int, float)):
array_value = np.array(value)
elif isinstance(value, (list, tuple)):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you also allow tuples? That is not shown in the type hints

DBF_values = DBF.values
return DBF_values

def _convert_to_scipp_variable(value: Union[int, float, list, np.ndarray, Parameter, sc.Variable], unit: str, name: str = "value") -> sc.Variable:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is unit a mandatory field? Especially since you reuse the unit if the input is a parameter?

return sc.scalar(value=float(array_value.flat[0]), unit=unit)
else:
# Multi-element array
return sc.array(dims=['x'], values=array_value, unit=unit) No newline at end of file

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this method meant to take multi-dimensional lists too? Won't you need more than 1 dim then?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not at the moment, no. It may also be that I need to revisit the dims if/when I move entirely to scipp for x, but I'll park that with the other headaches from the other PR for now :)

raise ValueError("energy_unit must be a string.")

if not isinstance(temperature_unit, str):
raise ValueError("temperature_unit must be a string.")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

raise ValueError("Temperature must be non-negative.")

# Convert energy to sc variable to make units easy to handle
energy = _convert_to_scipp_variable(energy, energy_unit, "energy")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message here would say that energy can be a Parameter, which it can't according to your type hints.

# We give users the option to specify the unit of the energy, but if the input has a unit, they might clash
if energy.unit != energy_unit:
warnings.warn(f"Input energy has unit {energy.unit}, but energy_unit was set to {energy_unit}. Using {energy.unit}.")
energy_unit = energy.unit

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you're doing here. I think I would do this slightly differently. Rather than having an energy_unit at all, I would simply raise a warning stating that the input was assumed to be in meV if it has no units. If the user just blindly passes a numpy array right now, they would not be informed that it was assumed to be in meV automatically.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're informed in the docstring - is that enough?
Args:
energy : number, list, np.ndarray, or scipp Variable. If number, assumed to be in meV unless energy_unit is set.
Energy transfer

# Same for temperature
if temperature.unit != temperature_unit:
warnings.warn(f"Input temperature has unit {temperature.unit}, but temperature_unit was set to {temperature_unit}. Using {temperature.unit}.")
temperature_unit = temperature.unit

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And again, the same here.

# Zero temperature deserves special treatment
if temperature.value == 0:
if divide_by_temperature:
raise ValueError("Cannot divide by T when T=0.")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can raise a specific ZeroDivisionError here ;)

Copy link

@damskii9992 damskii9992 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More comments


def _convert_to_scipp_variable(
value: Union[int, float, list, np.ndarray, Parameter, sc.Variable],
unit: Optional[str],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type hinting the unit argument does not make it optional :)
You need to add a default value for it, like None, if you wish to make it optional.

# Scalar or single-element array
try:
return sc.scalar(value=float(array_value.flat[0]), unit=unit)
except sc.UnitError as e:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you imported the UnitError from scipp, you don't need the "sc." prefix here or below :)

energy < 0.0 * sc.Unit(energy_unit), 0.0 * sc.Unit(energy_unit), energy
)

if DBF.sizes == {}:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

. . . . Does scipp return an empty dictionary when you get sizes on an empty array? O.o

# Small x (small energy and/or high temperature): Taylor expansion
small = sc.abs(x) < SMALL_THRESHOLD

DBF = sc.where(small, 1 + x / 2 + x**2 / 12, DBF)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List comprehension

DBF = sc.where(large, x, DBF)

# General case: exact formula
mid = ~small & ~large

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

~ should be not

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm. remove tildes and replace & with ==


# General case: exact formula
mid = ~small & ~large
DBF = sc.where(mid, x / (1 - sc.exp(-x)), DBF)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write comment: zeros handles by small treshold

Temperature
energy_unit : str, optional
Unit for energy if energy is given as a number or list. Default is 'meV'
temperature_unit : str, optional
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it refers to the formula instead of the function parameter

warnings.warn(
f"Input energy has unit {energy.unit}, but energy_unit was set to {energy_unit}. Using {energy.unit}."
)
energy_unit = energy.unit
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not use energy.unit in code below after converting to sc? That way, energy_unit will only be an argument for convert function and there won't be two variables with potentially different contents referring to essentially the same entity. Same for temperature

# Large x (large energy and/or low temperature): asymptotic form
large = x > LARGE_THRESHOLD
DBF = sc.where(large, x, DBF)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is x in small casted to abs, but not in large?

array_value = np.array(value.value)
unit = value.unit
else:
if name == "energy":
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if value is Parameter and name is "energy" it would be checked out by the elif isinstance(value, Parameter): in the upper part, this specific if statement for "energy" avoiding Parameter type does not make sense

def _convert_to_scipp_variable(
value: Union[int, float, list, np.ndarray, Parameter, sc.Variable],
unit: Optional[str] = None,
name: str = "value",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the default value is not used anywhere except error messages and it's not marked as optional.
imho it's a redundant parameter since you'll see the variable name that calls _convert_to_scipp_variable in the traceback anyway

Copy link

@damskii9992 damskii9992 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final comments to the tests now. The code itself looks good.

from easyscience import Parameter
from scipp.constants import Boltzmann as kB

from easydynamics.utils import _detailed_balance_factor as detailed_balance_factor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't remove the underscore in imports. At least not in code.

temperature_unit = 5
# Then Expect
with pytest.raises(TypeError, match="temperature_unit must be a string."):
detailed_balance_factor(energy, T, temperature_unit=temperature_unit)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a good idea to test multiple wrong inputs, not just a single one (here, you chose an int)

def test_scalar_input(self):
# When
energy = 2.0
T = 100

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering how often you set energy and temperature in your # When, it might be worth making this as a fixture to reuse throughout your tests.

energy / (1 - np.exp(-energy / (kB_meV_per_K * T))) / (kB_meV_per_K * T)
)

np.testing.assert_allclose(result, expected, rtol=1e-5)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you're testing for a scalar input, it probably makes sense to test that the output is also a scalar? :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an array, but good point


assert isinstance(result, np.ndarray)
assert result.shape == (3,)
np.testing.assert_allclose(result, expected_values, rtol=1e-5)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these tests could have been a single parametrized test with the energy input parametrized

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will keep this in mind for future tests ;)

def test_negative_temperature_raises(self):
# When Then Expect
with pytest.raises(ValueError, match="Temperature must be non-negative"):
detailed_balance_factor(1.0, -10)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The temperature tests can be collected and parametrized and the exception tests can also be collected and parametrized.

)
# Expect
expected = np.full(5, kB_meV_per_K * T)
np.testing.assert_allclose(result, expected, rtol=1e-5)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the expected value here also change with energy? Why are you simply comparing to a constant?
The way you're testing it right now, with a relative tolerance equal to your highest energy, you're not really testing anything.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good find, I messed that test up.

energy=energy, temperature=T, divide_by_temperature=False
)
# Expect
np.testing.assert_allclose(result, energy, rtol=1e-2)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not sure this test makes much sense. In the high-energy limit you set DBF = x. So shouldn't you just check that they're literally equal here? Instead of testing that they're close?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They differ on like 13th decimal, probably due to floating point things. I made the test much stricter.


# Expect
expected_ratio = np.exp(energy / (kB_meV_per_K * T))
np.testing.assert_allclose(ratio, expected_ratio, rtol=1e-5)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a comment for the reasoning behind this test? It is not obvious why the ratios should fulfill this condition. Think about your future programmers :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But everyone knows the principle of detailed balance, right? (I've added a comment)

expected = energy.values / (
1 - np.exp(-energy.values / 1000 / (kB_meV_per_K * T))
)
np.testing.assert_allclose(result, expected, rtol=1e-5)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't have a test of a faulty unit such as "m" or "l"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In both this and the temperature unit. I also don't think you have a test where you pass a sc.Unit?

@henrikjacobsenfys henrikjacobsenfys merged commit d51e07e into develop Oct 7, 2025
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[priority] medium Normal/default priority [scope] enhancement Adds/improves features (major.MINOR.patch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants